MultiTenancyなサービスにRBAC(Role Based Access Control)を実装した
はじめに
MultiTenancyなサービスにRBAC(Role Based Access Control)を実装したので紹介します。
以前、Auth0の機能を使ってRBACを実現する例を紹介しました。SingleTenantサービス、あるいはMultiTenancyであってもユーザとテナントがN:1(ユーザが所属できるテナントが1つだけ)であれば、以下の記事の方法で実現できます。
今回実装したのは、MultiTenancyサービスであり、ユーザとテナントがN:Nであるサービスであるため、上記の記事の方法だけでは実現ができません。具体的には、以下の要件を満たす必要があります。
- ユーザは複数のテナントに所属できる
- ユーザの権限はテナントごとに設定できる
分かりやすいサービスの例でいうと、チャットサービスのSlackでは、1つのユーザが複数のTeamに所属することができ、TeamごとにRole(権限)を設定することができます。本記事ではこのようなサービスのアクセスコントロールを実現します。
なお、今回も認証プロバイダとしてAuth0を利用していますが、Auth0のRolesやPermissionsといった機能は利用していないので、Auth0でなくても実現できます。
戦略
- 認証プロバイダのユーザ情報に所属するテナントと、そのテナントにおける権限を定義します。それらの情報は、idTokenのClaimにカスタムAttributeとして追加します。これにより改竄は不可になります。
- クライアントアプリケーション(SPA)は、認証プロバイダから受け取ったidTokenをそのままServerに受け渡します。本記事では触れていませんが、SPA側でもカスタムAttributeを参照してアクセスコントロールをすることはできます。(権限がないメニューは表示しないなど、あくまで表示上の制御です。)
- サーバアプリケーションでは、リクエストの対象となったテナントのRoleをidTokenから取り出して、Permissionに変換して、エンドポイントに必要なPermissionを満たしているかを検証します。(図では省略していますが、その前にidToken自体の検証は行います。)
実装
Koa.js + TypeScriptの実装例です。
router.ts
import Router from '@koa/router' import { verifyIdToken } from '../middlewares/verifyIdToken' import { verifyPermissions as permissions } from '../middlewares/verifyPermissions' const router = new Router<{}, any>() // verifyIdTokenがidTokenの検証 router.use(verifyIdToken) // permissionsがpermissionの検証 router.get('/contents', permissions(['content:read']), ContentsController.show) router.post('/contents', permissions(['content:write']), ContentsController.create)
- verifyPermissionsというmiddlewareを用意して、エンドポイントごとに必要なpermissionを定義し検証できるようにしています。
verifyIdToken.ts
export async function verifyIdToken(ctx: Context, next: Function): Promise<void> { // トークンの検証を行いtenantIdtとclaimsを取得した状態 ctx.permissions = await getPermissions(tenantId, claims['https://{{domain}}/app_metadata']) } await next() }
- idTokenを検証し、リクエスト対象のテナントに応じたpermissionsをコンテキストに設定します。
verifyPermissions.ts
import { AuthorizedApiContext } from './index' export function verifyPermissions(needsPermissions: string[]) { return async function(ctx: AuthorizedApiContext, next: Function) { if (!needsPermissions.every(permission => ctx.permissions.includes(permission))) { ctx.throw(403) // 権限がない } await next() } }
- コンテキストに設定されているpermissionsが、エンドポイントのアクセスに必要なpermissionsを満たしているか検証します。
- AuthorizedApiContextという型は、コンテキストにpermissionsというプロパティを持っているということを明示しています。(コンテキストの型の拡張については、別の記事で紹介したいと思います。)
おわりに
- ユーザとテナントがN:Nの関係のアクセスコントロールを実現することができました。
- ロールと権限が切り離されているので、あとからロールを追加したり、エンドポイントに必要な権限を変更したりすることも容易です。